Skip to content

Check the deletion tombstone in rules, not just in the profile callables - #183

Merged
renrenmimi merged 1 commit into
mainfrom
fix/deleted-account-tombstone-in-rules
Sep 3, 2026
Merged

Check the deletion tombstone in rules, not just in the profile callables#183
renrenmimi merged 1 commit into
mainfrom
fix/deleted-account-tombstone-in-rules

Conversation

@renrenmimi

Copy link
Copy Markdown
Owner

A deleted account could keep writing until its id token expired.

Why the absence read as "fine"

functions/src/users.ts:591-607 deletes users/{uid} and then deletes the Auth record last — deliberately, and the comment says why: "Auth is the last bridge we burn", so a partial cleanup failure is still retryable. Reasonable. But the rules read the resulting absence as good news:

helper post-cascade state verdict
isNotDeleting() :66 !exists(userDoc) not deleting ✅
isNotBanned() :53 !exists(adminDoc) not banned ✅

And rules validate only a JWT's signature and expiry — never that the Auth user still exists. So for the rest of that token's life (≤1h) the holder could:

  1. Re-create the world-readable user doc. completeOnboarding (users.ts:234-237) is a setDoc(..., {merge:true}), which with the doc deleted is a create — and firestore.rules:134 had neither isNotBanned() nor isNotDeleting() on it, while onboardingComplete is allowlisted.
  2. Like and bookmark other people's posts. Each like fires onLikeCreated and increments likeCount for a uid that no longer exists — and the cleanup pass that would have removed it has already finished.

Actor is the ex-owner, or anyone holding a token taken before deletion.

This is exactly the brake the comment at firestore.rules:59-62 claims to provide. It only ever covered the cascade while it ran.

The fix was half-built already

userDeletionTombstones/{uid} is written before the user doc is removed (users.ts:506-519) with a 24h TTL — chosen to outlive the 1h token, per its own comment. ensureUserProfileCallable and updateUserProfileCallable already check it (users.ts:291, :407).

Only the rules-governed paths didn't. So isNotDeleting() now checks both signals:

  • deletionPending — the cascade while it runs
  • the tombstone — after it finishes

That one change fixes every gate already built on isNotDeleting(): likes, bookmarks, settings, blockedUsers. Plus isNotDeleting() added to users/{uid} create and to the owner branch of update.

Rules-internal exists() is not governed by the tombstone collection's own allow read: if false, so it stays unreadable to clients.

Deletes stay open, same as for banned and mid-deletion accounts — a client tearing itself down must still be able to remove rows the cascade missed. There's a test for that.

Cost

One extra exists() wherever isNotDeleting() is reached. Worst case is a like create: isNotBanned (≤2) + isNotDeleting (≤3) + exists(posts/…) (1) = 6 document accesses, against Firestore's limit of 10 per single-document request.

Tests

tests/rules/ban-and-engagement.test.ts, 18 → 25 tests.

Four fail on the old rules and pass on these:

× cannot rebuild its own user document
× cannot like other people's posts
× cannot create bookmarks
× cannot block anyone or write settings

The fixture is what deleteUserAccount actually leaves behind: no user doc, no admin/state, tombstone present.

Three pass on both, on purpose. Two of them matter a lot: a brand-new signup has no tombstone and no user doc — the same "absent" the bug relied on — so they pin that a fresh uid can still create its profile and like a post. That's the guard against fixing this by breaking onboarding. The third pins that a finished account can still delete its own leftovers.

Full local run: rules 52/52, functions test:emulator 73/73, root lint / typecheck:tests / build clean.

Deploy

Rules only.

firebase deploy --only firestore:rules

Note on stacking

This branches from main, not from #182, and both touch firestore.rules in different places (isAdmin / admin/state there, isNotDeleting / users create-update here). Whichever merges second will likely need a trivial rebase — worth taking them one at a time rather than together.

🤖 Generated with Claude Code

Copilot AI lite review requested due to automatic review settings September 3, 2026 20:11
@vercel

vercel Bot commented Sep 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
pet-note Ready Ready Preview Sep 3, 2026 8:52pm UTC

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

A deleted account could keep writing until its id token expired.

The cascade deletes users/{uid} and admin/state, then deletes the Auth record
last — deliberately, so a partial failure is still retryable ("Auth is the
last bridge we burn"). But rules read the resulting absence as good news:

  isNotDeleting()  `!exists(userDoc)`  → not deleting
  isNotBanned()    `!exists(adminDoc)` → not banned

and rules validate only a JWT's signature and expiry, never that the Auth user
still exists. So for the rest of that token's life the holder — the ex-owner,
or anyone who took the token — could re-create the world-readable user doc
through completeOnboarding (a setDoc merge, which is a CREATE once the doc is
gone, and onboardingComplete is allowlisted), then like and bookmark other
people's posts. Each like fired onLikeCreated and incremented likeCount for a
uid that no longer exists, with no cleanup pass left to revisit it.

users/{uid} create had neither isNotBanned() nor isNotDeleting() on it at all.

The mechanism to fix it already existed and was only half-wired.
userDeletionTombstones/{uid} is written BEFORE the user doc is removed, with a
24h TTL that comfortably outlives the 1h token it exists to outlive, and
ensureUserProfileCallable and updateUserProfileCallable already check it. The
paths governed by rules did not.

isNotDeleting() now checks both signals: deletionPending covers the cascade
while it runs, the tombstone covers after it finishes. Every gate already
built on isNotDeleting() — likes, bookmarks, settings, blockedUsers — is fixed
by that one change, and isNotDeleting() is added to users/{uid} create and to
the owner branch of update. Rules-internal exists() is not governed by the
tombstone collection's own `allow read: if false`.

Deletes stay open, same as for banned and mid-deletion accounts: a client
tearing itself down must still be able to remove rows the cascade missed.

Four new tests fail on the old rules and pass on these. Three more pass on
both, on purpose: two pin that a brand-new signup — no tombstone and no user
doc, which is the same "absent" the bug relied on — can still create its
profile and like a post, and one pins that a finished account can still delete
its own leftovers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@renrenmimi
renrenmimi force-pushed the fix/deleted-account-tombstone-in-rules branch from cf69662 to 3b829ff Compare September 3, 2026 20:51
@renrenmimi
renrenmimi merged commit 94cd3b0 into main Sep 3, 2026
6 checks passed
@renrenmimi
renrenmimi deleted the fix/deleted-account-tombstone-in-rules branch September 3, 2026 20:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants